
今天寫一個能跑的 MCP server,然後真的掛到 Claude Code 上讓它呼叫。
底下每一段程式碼我都跑過,最後那段對話輸出是實際跑出來的,不是我編的。
Python 標準函式庫,零依賴。九十行,其中一半是資料和說明文字。
#!/usr/bin/env python3
"""最小 MCP server:stdin/stdout 上的 JSON-RPC 2.0。"""
import json, sys
TASKS = [
{"id": 1, "title": "修好排程的鎖檔漂移", "status": "doing"},
{"id": 2, "title": "把工具 schema 砍一半", "status": "todo"},
{"id": 3, "title": "寫 Day 8 的文章", "status": "done"},
]
TOOLS = [{
"name": "task_list",
"description": (
"列出待辦任務。當使用者問「有什麼要做的」、「進度如何」,"
"或你需要知道目前有哪些工作在進行時使用。"
),
"inputSchema": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["todo", "doing", "done"],
"description": "只列出這個狀態的任務,省略則列出全部",
}
},
"required": [],
},
}]
def log(msg: str) -> None:
print(msg, file=sys.stderr, flush=True) # 日誌一律走 stderr
def call_tool(name: str, args: dict) -> dict:
if name != "task_list":
return {"content": [{"type": "text", "text": f"未知工具:{name}"}],
"isError": True}
status = args.get("status")
rows = [t for t in TASKS if status is None or t["status"] == status]
text = ("沒有符合條件的任務。" if not rows else
"\n".join(f"[{t['status']}] #{t['id']} {t['title']}" for t in rows))
return {"content": [{"type": "text", "text": text}], "isError": False}
def handle(req: dict) -> dict | None:
method, rid = req.get("method"), req.get("id")
if method == "initialize":
result = {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "demo-tasks", "version": "0.1.0"},
}
elif method == "tools/list":
result = {"tools": TOOLS}
elif method == "tools/call":
p = req.get("params", {})
result = call_tool(p.get("name", ""), p.get("arguments") or {})
elif method and method.startswith("notifications/"):
return None # 通知沒有 id,不能回覆
else:
return {"jsonrpc": "2.0", "id": rid,
"error": {"code": -32601, "message": f"Method not found: {method}"}}
return {"jsonrpc": "2.0", "id": rid, "result": result}
def main() -> None:
log("demo-tasks MCP server 啟動")
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
req = json.loads(line)
except json.JSONDecodeError as e:
log(f"解析失敗:{e}")
continue
resp = handle(req)
if resp is not None:
print(json.dumps(resp, ensure_ascii=False), flush=True)
if __name__ == "__main__":
main()
在把它接到 Claude Code 之前,先確認協定層是對的。這一步能省下大量除錯時間,因為透過客戶端測的時候,錯誤訊息通常只有一句「server 沒有回應」。
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{}}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"task_list","arguments":{"status":"todo"}}}' \
| python3 server.py 2>/dev/null
我實際跑出來的三行回應(節錄):
id 1 -> {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}}, ...}
id 2 -> {"tools": [{"name": "task_list", "description": "列出待辦任務。當使用者問…
id 3 -> {"content": [{"type": "text", "text": "[todo] #2 把工具 schema 砍一半"}], "isError": false}
三個都對,可以掛上去了。

整個協定要能動起來只有這三次往返。右邊那條指向旁邊的箭頭是日誌,一定要走 stderr,寫進 stdout 會把協定弄壞。
寫一份設定檔:
{
"mcpServers": {
"demo-tasks": {
"command": "python3",
"args": ["/絕對路徑/server.py"]
}
}
}
路徑要用絕對路徑。相對路徑會相對於客戶端的工作目錄,而那個目錄未必是你以為的那個。
然後呼叫:
claude -p "現在有哪些還沒開始做的任務?" \
--mcp-config ./mcp.json \
--strict-mcp-config \
--tools "" \
--allowedTools "mcp__demo-tasks__task_list" \
--setting-sources project,local \
< /dev/null
實際輸出:
還沒開始的任務只有一項:
- **#2** 把工具 schema 砍一半
需要我看一下 doing / done 的狀態,或著手處理 #2 嗎?
它自己判斷「還沒開始做」對應到 status: "todo",呼叫了工具,然後把結果講成人話。整個過程我沒有告訴它有這個工具,也沒有教它參數怎麼填,那份 schema 就是它全部的資訊來源。
注意 --tools "" 那行:內建工具全部關掉,這個 agent 只有我給它的那一個 MCP 工具。固定開銷因此極低,而它照樣完成了任務。這就是昨天講的「只帶那顆燈泡進門」。
工具名稱的格式是 mcp__<server名稱>__<工具名稱>,兩個底線。寫錯的話 --allowedTools 會靜默失效,工具還是能被呼叫(因為沒有匹配到任何限制規則),你會以為權限有生效,其實沒有。
一、stdout 是協定專用的。
我第一版在 call_tool 裡加了一行 print(f"呼叫 {name}") 除錯。整個 server 當場壞掉,客戶端說解析失敗。因為那行字被寫進了 stdout,混在 JSON-RPC 訊息流裡。
所有日誌走 stderr,沒有例外。如果你的 server 有用到任何會印東西的第三方套件,要特別檢查它印去哪裡。
二、通知類訊息不能回覆。
客戶端會送 notifications/initialized 這類訊息,它們沒有 id 欄位。如果你照常回一個 {"id": null, "result": ...},有些客戶端會當成協定違規。
處理方式就是上面那行:method 以 notifications/ 開頭就直接 return None。
三、headless 模式的 stdin 陷阱。
我第一次跑的時候看到這行警告:
Warning: no stdin data received in 3s, proceeding without it.
claude -p 會等 stdin 三秒,看你是不是要從管線餵資料進去。在腳本裡這三秒是純浪費,而且如果你的腳本剛好在某個會 hang 住 stdin 的環境跑,它會一直等。
加 < /dev/null 明確告訴它沒有輸入。自動化腳本一律加。
四、錯誤要走 isError,不要走 JSON-RPC error。
# ✗ 協定層錯誤:客戶端可能直接中斷,模型看不到發生什麼事
return {"jsonrpc": "2.0", "id": rid,
"error": {"code": -32000, "message": "檔案不存在"}}
# ✓ 工具層錯誤:模型看得到,可以自己修正參數重試
return {"content": [{"type": "text",
"text": "找不到檔案 config.toml,請確認路徑"}], "isError": True}
第二種寫法的價值在於,模型收到之後常常會自己修正。我看過它拿到「找不到 config.toml」之後,改去呼叫另一個工具查目錄,找到正確路徑再試一次。這種自我修正只有在錯誤訊息進得到模型的視野裡才會發生。
讀取工具很安全,寫入工具要多想一層。加上 task_update:
TOOLS.append({
"name": "task_update",
"description": "更新任務狀態。只能改 status,不能改標題。",
"inputSchema": {
"type": "object",
"properties": {
"id": {"type": "integer", "description": "任務編號"},
"status": {"type": "string", "enum": ["todo", "doing", "done"]},
},
"required": ["id", "status"],
},
})
def task_update(args: dict) -> dict:
tid, status = args.get("id"), args.get("status")
# 邊界驗證:不要相信參數,即使 schema 說了型別
if not isinstance(tid, int):
return {"content": [{"type": "text", "text": "id 必須是整數"}],
"isError": True}
if status not in {"todo", "doing", "done"}:
return {"content": [{"type": "text", "text": f"不合法的狀態:{status}"}],
"isError": True}
for t in TASKS:
if t["id"] == tid:
old = t["status"]
t["status"] = status
log(f"task {tid}: {old} -> {status}") # 寫入操作一定要留紀錄
return {"content": [{"type": "text",
"text": f"#{tid} 已從 {old} 改為 {status}"}], "isError": False}
return {"content": [{"type": "text", "text": f"找不到任務 #{tid}"}],
"isError": True}
兩個重點。
schema 不是驗證。 inputSchema 是給模型看的說明,不是執行期的保證。模型可能傳字串 "2" 而不是整數 2,也可能傳一個不在 enum 裡的值。所有參數在你的程式裡都要重新驗證一次。這是邊界驗證的基本功,MCP 工具就是你系統的邊界。
寫入操作要留紀錄。 那行 log 看起來可有可無,等到你要回答「這個狀態是誰改的、什麼時候改的」時,它就是唯一的證據。這個主題在 Day 18 會展開,因為它後來變成我整個系統裡最重要的一份檔案。
這個 server 有幾個地方是為了篇幅簡化的,實際用要補上。
沒有並發保護。 兩個 agent 同時呼叫 task_update,資料會亂。實務上要嘛用資料庫,要嘛在寫檔時加檔案鎖。
狀態在記憶體。 server 重啟就回到初始值。真的要用得寫進 SQLite 或檔案。
沒有身分概念。 誰呼叫的?他有權限改這筆任務嗎?這個 server 完全不知道。單人用沒差,多 agent 環境下這是個大洞,Day 26 會講怎麼補。
還有一件事值得先想:這個 server 現在有兩個工具,schema 大概 300 個 token。加到二十個工具就是三千,加到兩百個就是三萬,而那是每次呼叫都要付的。
明天講這件事:宣告一個工具的成本,以及為什麼「隱藏工具」跟「禁用工具」是兩回事。